{
  "cells": [
    {
      "cell_type": "markdown",
      "id": "ca58dec8",
      "metadata": {
        "id": "ca58dec8"
      },
      "source": [
        "## Install (Colab Only)\n",
        "Skip this step when running locally."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 1,
      "id": "a13701c2",
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "a13701c2",
        "outputId": "6717fd0a-0f78-41c0-ab7e-b918e132e906"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Collecting pyepo\n",
            "  Downloading pyepo-1.0.0-py3-none-any.whl.metadata (1.6 kB)\n",
            "Requirement already satisfied: numpy in /usr/local/lib/python3.11/dist-packages (from pyepo) (1.26.4)\n",
            "Requirement already satisfied: scipy in /usr/local/lib/python3.11/dist-packages (from pyepo) (1.13.1)\n",
            "Collecting pathos (from pyepo)\n",
            "  Downloading pathos-0.3.3-py3-none-any.whl.metadata (11 kB)\n",
            "Requirement already satisfied: tqdm in /usr/local/lib/python3.11/dist-packages (from pyepo) (4.67.1)\n",
            "Collecting configspace (from pyepo)\n",
            "  Downloading configspace-1.2.1.tar.gz (130 kB)\n",
            "\u001b[2K     \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m131.0/131.0 kB\u001b[0m \u001b[31m2.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25h  Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n",
            "  Getting requirements to build wheel ... \u001b[?25l\u001b[?25hdone\n",
            "  Preparing metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
            "Requirement already satisfied: scikit_learn in /usr/local/lib/python3.11/dist-packages (from pyepo) (1.6.1)\n",
            "Requirement already satisfied: torch>=1.13.1 in /usr/local/lib/python3.11/dist-packages (from pyepo) (2.5.1+cu124)\n",
            "Requirement already satisfied: filelock in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (3.17.0)\n",
            "Requirement already satisfied: typing-extensions>=4.8.0 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (4.12.2)\n",
            "Requirement already satisfied: networkx in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (3.4.2)\n",
            "Requirement already satisfied: jinja2 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (3.1.5)\n",
            "Requirement already satisfied: fsspec in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (2024.10.0)\n",
            "Collecting nvidia-cuda-nvrtc-cu12==12.4.127 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Collecting nvidia-cuda-runtime-cu12==12.4.127 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Collecting nvidia-cuda-cupti-cu12==12.4.127 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
            "Collecting nvidia-cudnn-cu12==9.1.0.70 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
            "Collecting nvidia-cublas-cu12==12.4.5.8 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Collecting nvidia-cufft-cu12==11.2.1.3 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Collecting nvidia-curand-cu12==10.3.5.147 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Collecting nvidia-cusolver-cu12==11.6.1.9 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
            "Collecting nvidia-cusparse-cu12==12.3.1.170 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
            "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (2.21.5)\n",
            "Requirement already satisfied: nvidia-nvtx-cu12==12.4.127 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (12.4.127)\n",
            "Collecting nvidia-nvjitlink-cu12==12.4.127 (from torch>=1.13.1->pyepo)\n",
            "  Downloading nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
            "Requirement already satisfied: triton==3.1.0 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (3.1.0)\n",
            "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.11/dist-packages (from torch>=1.13.1->pyepo) (1.13.1)\n",
            "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.11/dist-packages (from sympy==1.13.1->torch>=1.13.1->pyepo) (1.3.0)\n",
            "Requirement already satisfied: pyparsing in /usr/local/lib/python3.11/dist-packages (from configspace->pyepo) (3.2.1)\n",
            "Requirement already satisfied: more_itertools in /usr/local/lib/python3.11/dist-packages (from configspace->pyepo) (10.6.0)\n",
            "Collecting ppft>=1.7.6.9 (from pathos->pyepo)\n",
            "  Downloading ppft-1.7.6.9-py3-none-any.whl.metadata (12 kB)\n",
            "Collecting dill>=0.3.9 (from pathos->pyepo)\n",
            "  Downloading dill-0.3.9-py3-none-any.whl.metadata (10 kB)\n",
            "Collecting pox>=0.3.5 (from pathos->pyepo)\n",
            "  Downloading pox-0.3.5-py3-none-any.whl.metadata (8.0 kB)\n",
            "Collecting multiprocess>=0.70.17 (from pathos->pyepo)\n",
            "  Downloading multiprocess-0.70.17-py311-none-any.whl.metadata (7.2 kB)\n",
            "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.11/dist-packages (from scikit_learn->pyepo) (1.4.2)\n",
            "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.11/dist-packages (from scikit_learn->pyepo) (3.5.0)\n",
            "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.11/dist-packages (from jinja2->torch>=1.13.1->pyepo) (3.0.2)\n",
            "Downloading pyepo-1.0.0-py3-none-any.whl (52 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m52.2/52.2 kB\u001b[0m \u001b[31m2.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl (363.4 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m363.4/363.4 MB\u001b[0m \u001b[31m4.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (13.8 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.8/13.8 MB\u001b[0m \u001b[31m88.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (24.6 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m24.6/24.6 MB\u001b[0m \u001b[31m73.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (883 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m883.7/883.7 kB\u001b[0m \u001b[31m40.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl (664.8 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m664.8/664.8 MB\u001b[0m \u001b[31m2.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl (211.5 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m211.5/211.5 MB\u001b[0m \u001b[31m5.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl (56.3 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m56.3/56.3 MB\u001b[0m \u001b[31m9.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl (127.9 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m127.9/127.9 MB\u001b[0m \u001b[31m7.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl (207.5 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m207.5/207.5 MB\u001b[0m \u001b[31m5.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (21.1 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m21.1/21.1 MB\u001b[0m \u001b[31m51.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading pathos-0.3.3-py3-none-any.whl (82 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m82.1/82.1 kB\u001b[0m \u001b[31m5.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading dill-0.3.9-py3-none-any.whl (119 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m119.4/119.4 kB\u001b[0m \u001b[31m9.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading multiprocess-0.70.17-py311-none-any.whl (144 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m144.3/144.3 kB\u001b[0m \u001b[31m8.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading pox-0.3.5-py3-none-any.whl (29 kB)\n",
            "Downloading ppft-1.7.6.9-py3-none-any.whl (56 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m56.8/56.8 kB\u001b[0m \u001b[31m4.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hBuilding wheels for collected packages: configspace\n",
            "  Building wheel for configspace (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
            "  Created wheel for configspace: filename=configspace-1.2.1-py3-none-any.whl size=115950 sha256=d7e30c139f847d6b9d1db988ddf83a45fa760696dd2396f3518f1299bab16eff\n",
            "  Stored in directory: /root/.cache/pip/wheels/11/0f/36/d5027c3eeb038827889830f7efbe6a1bad8956b3eb44ab2f44\n",
            "Successfully built configspace\n",
            "Installing collected packages: ppft, pox, nvidia-nvjitlink-cu12, nvidia-curand-cu12, nvidia-cufft-cu12, nvidia-cuda-runtime-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-cupti-cu12, nvidia-cublas-cu12, dill, nvidia-cusparse-cu12, nvidia-cudnn-cu12, multiprocess, configspace, pathos, nvidia-cusolver-cu12, pyepo\n",
            "  Attempting uninstall: nvidia-nvjitlink-cu12\n",
            "    Found existing installation: nvidia-nvjitlink-cu12 12.5.82\n",
            "    Uninstalling nvidia-nvjitlink-cu12-12.5.82:\n",
            "      Successfully uninstalled nvidia-nvjitlink-cu12-12.5.82\n",
            "  Attempting uninstall: nvidia-curand-cu12\n",
            "    Found existing installation: nvidia-curand-cu12 10.3.6.82\n",
            "    Uninstalling nvidia-curand-cu12-10.3.6.82:\n",
            "      Successfully uninstalled nvidia-curand-cu12-10.3.6.82\n",
            "  Attempting uninstall: nvidia-cufft-cu12\n",
            "    Found existing installation: nvidia-cufft-cu12 11.2.3.61\n",
            "    Uninstalling nvidia-cufft-cu12-11.2.3.61:\n",
            "      Successfully uninstalled nvidia-cufft-cu12-11.2.3.61\n",
            "  Attempting uninstall: nvidia-cuda-runtime-cu12\n",
            "    Found existing installation: nvidia-cuda-runtime-cu12 12.5.82\n",
            "    Uninstalling nvidia-cuda-runtime-cu12-12.5.82:\n",
            "      Successfully uninstalled nvidia-cuda-runtime-cu12-12.5.82\n",
            "  Attempting uninstall: nvidia-cuda-nvrtc-cu12\n",
            "    Found existing installation: nvidia-cuda-nvrtc-cu12 12.5.82\n",
            "    Uninstalling nvidia-cuda-nvrtc-cu12-12.5.82:\n",
            "      Successfully uninstalled nvidia-cuda-nvrtc-cu12-12.5.82\n",
            "  Attempting uninstall: nvidia-cuda-cupti-cu12\n",
            "    Found existing installation: nvidia-cuda-cupti-cu12 12.5.82\n",
            "    Uninstalling nvidia-cuda-cupti-cu12-12.5.82:\n",
            "      Successfully uninstalled nvidia-cuda-cupti-cu12-12.5.82\n",
            "  Attempting uninstall: nvidia-cublas-cu12\n",
            "    Found existing installation: nvidia-cublas-cu12 12.5.3.2\n",
            "    Uninstalling nvidia-cublas-cu12-12.5.3.2:\n",
            "      Successfully uninstalled nvidia-cublas-cu12-12.5.3.2\n",
            "  Attempting uninstall: nvidia-cusparse-cu12\n",
            "    Found existing installation: nvidia-cusparse-cu12 12.5.1.3\n",
            "    Uninstalling nvidia-cusparse-cu12-12.5.1.3:\n",
            "      Successfully uninstalled nvidia-cusparse-cu12-12.5.1.3\n",
            "  Attempting uninstall: nvidia-cudnn-cu12\n",
            "    Found existing installation: nvidia-cudnn-cu12 9.3.0.75\n",
            "    Uninstalling nvidia-cudnn-cu12-9.3.0.75:\n",
            "      Successfully uninstalled nvidia-cudnn-cu12-9.3.0.75\n",
            "  Attempting uninstall: nvidia-cusolver-cu12\n",
            "    Found existing installation: nvidia-cusolver-cu12 11.6.3.83\n",
            "    Uninstalling nvidia-cusolver-cu12-11.6.3.83:\n",
            "      Successfully uninstalled nvidia-cusolver-cu12-11.6.3.83\n",
            "Successfully installed configspace-1.2.1 dill-0.3.9 multiprocess-0.70.17 nvidia-cublas-cu12-12.4.5.8 nvidia-cuda-cupti-cu12-12.4.127 nvidia-cuda-nvrtc-cu12-12.4.127 nvidia-cuda-runtime-cu12-12.4.127 nvidia-cudnn-cu12-9.1.0.70 nvidia-cufft-cu12-11.2.1.3 nvidia-curand-cu12-10.3.5.147 nvidia-cusolver-cu12-11.6.1.9 nvidia-cusparse-cu12-12.3.1.170 nvidia-nvjitlink-cu12-12.4.127 pathos-0.3.3 pox-0.3.5 ppft-1.7.6.9 pyepo-1.0.0\n",
            "Collecting gurobipy\n",
            "  Downloading gurobipy-12.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (16 kB)\n",
            "Downloading gurobipy-12.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (14.4 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m14.4/14.4 MB\u001b[0m \u001b[31m56.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hInstalling collected packages: gurobipy\n",
            "Successfully installed gurobipy-12.0.1\n",
            "Collecting mpax\n",
            "  Downloading mpax-0.2.0-py3-none-any.whl.metadata (9.3 kB)\n",
            "Requirement already satisfied: chex<0.2.0,>=0.1.87 in /usr/local/lib/python3.11/dist-packages (from mpax) (0.1.89)\n",
            "Collecting jax<0.5.0,>=0.4.35 (from mpax)\n",
            "  Downloading jax-0.4.38-py3-none-any.whl.metadata (22 kB)\n",
            "Requirement already satisfied: numpy<2.3,>1.24.4 in /usr/local/lib/python3.11/dist-packages (from mpax) (1.26.4)\n",
            "Requirement already satisfied: absl-py>=0.9.0 in /usr/local/lib/python3.11/dist-packages (from chex<0.2.0,>=0.1.87->mpax) (1.4.0)\n",
            "Requirement already satisfied: typing_extensions>=4.2.0 in /usr/local/lib/python3.11/dist-packages (from chex<0.2.0,>=0.1.87->mpax) (4.12.2)\n",
            "Requirement already satisfied: jaxlib>=0.4.27 in /usr/local/lib/python3.11/dist-packages (from chex<0.2.0,>=0.1.87->mpax) (0.4.33)\n",
            "Requirement already satisfied: toolz>=0.9.0 in /usr/local/lib/python3.11/dist-packages (from chex<0.2.0,>=0.1.87->mpax) (0.12.1)\n",
            "Collecting jaxlib>=0.4.27 (from chex<0.2.0,>=0.1.87->mpax)\n",
            "  Downloading jaxlib-0.4.38-cp311-cp311-manylinux2014_x86_64.whl.metadata (1.0 kB)\n",
            "Requirement already satisfied: ml_dtypes>=0.4.0 in /usr/local/lib/python3.11/dist-packages (from jax<0.5.0,>=0.4.35->mpax) (0.4.1)\n",
            "Requirement already satisfied: opt_einsum in /usr/local/lib/python3.11/dist-packages (from jax<0.5.0,>=0.4.35->mpax) (3.4.0)\n",
            "Requirement already satisfied: scipy>=1.10 in /usr/local/lib/python3.11/dist-packages (from jax<0.5.0,>=0.4.35->mpax) (1.13.1)\n",
            "Downloading mpax-0.2.0-py3-none-any.whl (48 kB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m48.4/48.4 kB\u001b[0m \u001b[31m2.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading jax-0.4.38-py3-none-any.whl (2.2 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.2/2.2 MB\u001b[0m \u001b[31m31.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hDownloading jaxlib-0.4.38-cp311-cp311-manylinux2014_x86_64.whl (101.8 MB)\n",
            "\u001b[2K   \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m101.8/101.8 MB\u001b[0m \u001b[31m8.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
            "\u001b[?25hInstalling collected packages: jaxlib, jax, mpax\n",
            "  Attempting uninstall: jaxlib\n",
            "    Found existing installation: jaxlib 0.4.33\n",
            "    Uninstalling jaxlib-0.4.33:\n",
            "      Successfully uninstalled jaxlib-0.4.33\n",
            "  Attempting uninstall: jax\n",
            "    Found existing installation: jax 0.4.33\n",
            "    Uninstalling jax-0.4.33:\n",
            "      Successfully uninstalled jax-0.4.33\n",
            "Successfully installed jax-0.4.38 jaxlib-0.4.38 mpax-0.2.0\n"
          ]
        }
      ],
      "source": [
        "# install\n",
        "!pip install pyepo\n",
        "!pip install gurobipy\n",
        "!pip install mpax"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "voxnIrQE0_ev",
      "metadata": {
        "id": "voxnIrQE0_ev"
      },
      "source": [
        "Remove the problematic hook in Google Colab"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 2,
      "id": "-nqj33r90_pG",
      "metadata": {
        "id": "-nqj33r90_pG"
      },
      "outputs": [],
      "source": [
        "import sys\n",
        "sys.meta_path = [hook for hook in sys.meta_path if not any(keyword in str(hook) for keyword in [\"google.colab\"])]"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "ebb79cf0",
      "metadata": {
        "id": "ebb79cf0"
      },
      "source": [
        "# Optimization Model"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "1c786b61",
      "metadata": {
        "id": "1c786b61"
      },
      "source": [
        "PyEPO is an implementation that aims to support an end-to-end predict-then-optimize with linear objective function and unknown cost coefficients. The core component is the differentiable optimization solver, which is involved in updating the gradient of the cost coefficients with respect to the optimal solution.\n",
        "\n",
        "For ease of use, the implementation extends PyTorch's automatic function to construct the solver. As a result, users have the flexibility to select the solvers and algorithms and subsequently obtain the optimal solution while simultaneously computing the gradient.\n",
        "\n",
        "This tutorial will provide examples of building optimization models ``optModel`` with PyEPO."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "1d22204c",
      "metadata": {
        "id": "1d22204c"
      },
      "source": [
        "## 1 Problem Example: Shortest Path"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "687feac4",
      "metadata": {
        "id": "687feac4"
      },
      "source": [
        "Consider a 5x5 grid network, represented as a weighted graph. The figure shows that each node has top, bottom, left, and right neighbors. We aim to find the shortest path from left top to right bottom."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "d3b6d561",
      "metadata": {
        "id": "d3b6d561"
      },
      "source": [
        "<img src=\"https://github.com/khalil-research/PyEPO/blob/main/images/shortestpath.png?raw=1\" width=\"500\">"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "66ed00fc",
      "metadata": {
        "id": "66ed00fc"
      },
      "source": [
        "The weighted graph includes 25 nodes and 40 edges. The weights of the edges are the costs of the path. Let's set random weights."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 3,
      "id": "102b96aa",
      "metadata": {
        "id": "102b96aa"
      },
      "outputs": [],
      "source": [
        "import random\n",
        "# random seed\n",
        "random.seed(42)\n",
        "# set random cost for test\n",
        "cost = [random.random() for _ in range(40)]"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "f740f6bb",
      "metadata": {
        "id": "f740f6bb"
      },
      "source": [
        "## 2 Introducation to optModel"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "86a5a7dd",
      "metadata": {
        "id": "86a5a7dd"
      },
      "source": [
        "``optModel`` is a module of PyEPO library. It is not a solver but serves as a container of a solver or an algorithm. This design allows for flexibility in the selection of solvers and algorithms by users. ``optModel`` treats these solvers as black boxes and provides interfaces ``_getModel``, ``setObj``, and ``solve``. Other modules of PyEPO can use ``optModel`` for tasks such as training and testing.\n",
        "\n",
        "Methods of ``optModel``:\n",
        "- ``_getModel``: Build and return optimization solver and corresponding decision variables.\n",
        "- ``setObj``: Give a cost vector to set the objective function.\n",
        "- ``solve``: Solve optimization problem and return optimal solution and objective value."
      ]
    },
    {
      "cell_type": "markdown",
      "id": "34d3dcc7",
      "metadata": {
        "id": "34d3dcc7"
      },
      "source": [
        "### 2.1 Build Model with NetworkX and Dijkstra Agorithm"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "9999f97b",
      "metadata": {
        "id": "9999f97b"
      },
      "source": [
        "We can build the graph with NetworkX and uses Dijkstra’s method to compute the shortest weighted path between two nodes in a graph."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 4,
      "id": "4f13bef4",
      "metadata": {
        "id": "4f13bef4"
      },
      "outputs": [],
      "source": [
        "import networkx as nx\n",
        "import numpy as np\n",
        "from pyepo.model.opt import optModel\n",
        "\n",
        "class myShortestPathModel1(optModel):\n",
        "\n",
        "    def __init__(self, grid):\n",
        "        \"\"\"\n",
        "        Args:\n",
        "            grid (tuple): size of grid network\n",
        "        \"\"\"\n",
        "        self.grid = grid\n",
        "        self.arcs = self._getArcs()\n",
        "        super().__init__()\n",
        "\n",
        "    def _getModel(self):\n",
        "        \"\"\"\n",
        "        A method to build model\n",
        "\n",
        "        Returns:\n",
        "            tuple: optimization model and variables\n",
        "        \"\"\"\n",
        "        # build graph as optimization model\n",
        "        g = nx.Graph()\n",
        "        # add arcs as variables\n",
        "        g.add_edges_from(self.arcs, cost=0)\n",
        "        return g, g.edges\n",
        "\n",
        "    def setObj(self, c):\n",
        "        \"\"\"\n",
        "        A method to set objective function\n",
        "\n",
        "        Args:\n",
        "            c (ndarray): cost of objective function\n",
        "        \"\"\"\n",
        "        # set weights for edges\n",
        "        for i, e in enumerate(self.arcs):\n",
        "            self._model.edges[e][\"cost\"] = c[i]\n",
        "\n",
        "    def solve(self):\n",
        "        \"\"\"\n",
        "        A method to solve model\n",
        "\n",
        "        Returns:\n",
        "            tuple: optimal solution (list) and objective value (float)\n",
        "        \"\"\"\n",
        "        # dijkstra\n",
        "        path = nx.shortest_path(self._model, weight=\"cost\", source=0, target=self.grid[0]*self.grid[1]-1)\n",
        "        # convert path into active edges\n",
        "        edges = []\n",
        "        u = 0\n",
        "        for v in path[1:]:\n",
        "            edges.append((u,v))\n",
        "            u = v\n",
        "        # init sol & obj\n",
        "        sol = np.zeros(self.num_cost)\n",
        "        obj = 0\n",
        "        # convert active edges into solution and obj\n",
        "        for i, e in enumerate(self.arcs):\n",
        "            if e in edges:\n",
        "                sol[i] = 1 # active edge\n",
        "                obj += self._model.edges[e][\"cost\"] # cost of active edge\n",
        "        return sol, obj\n",
        "\n",
        "    def _getArcs(self):\n",
        "        \"\"\"\n",
        "        A helper method to get list of arcs for grid network\n",
        "\n",
        "        Returns:\n",
        "            list: arcs\n",
        "        \"\"\"\n",
        "        arcs = []\n",
        "        for i in range(self.grid[0]):\n",
        "            # edges on rows\n",
        "            for j in range(self.grid[1] - 1):\n",
        "                v = i * self.grid[1] + j\n",
        "                arcs.append((v, v + 1))\n",
        "            # edges on columns\n",
        "            if i == self.grid[0] - 1:\n",
        "                continue\n",
        "            for j in range(self.grid[1]):\n",
        "                v = i * self.grid[1] + j\n",
        "                arcs.append((v, v + self.grid[1]))\n",
        "        return arcs"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 5,
      "id": "9e1c381b",
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "9e1c381b",
        "outputId": "4c2026d2-712d-4750-bdbe-a006a0a4517b"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Obj: 2.2869938328922332\n",
            "(0, 1)\n",
            "(1, 2)\n",
            "(2, 3)\n",
            "(3, 8)\n",
            "(8, 9)\n",
            "(9, 14)\n",
            "(14, 19)\n",
            "(19, 24)\n"
          ]
        }
      ],
      "source": [
        "# solve model\n",
        "optmodel = myShortestPathModel1(grid=(5,5)) # init model\n",
        "optmodel.setObj(cost) # set objective function\n",
        "sol, obj = optmodel.solve() # solve\n",
        "# print res\n",
        "print('Obj: {}'.format(obj))\n",
        "for i, e in enumerate(optmodel.arcs):\n",
        "    if sol[i] > 1e-3:\n",
        "        print(e)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "7a22c77a",
      "metadata": {
        "id": "7a22c77a"
      },
      "source": [
        "### 2.2 Build Model with GurobiPy and Linear Programming"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "6014f360",
      "metadata": {
        "id": "6014f360"
      },
      "source": [
        "``optModel`` also allows users to employ optimization modeling languages such as GurobiPy and Pyomo. For example, with ``optGrbModel``, users can easily use Gurobi to create an LP model via overwriting ``_getModel``. Similarly, ``optGrbModel`` support Pyomo."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 6,
      "id": "e64fbf71",
      "metadata": {
        "id": "e64fbf71"
      },
      "outputs": [],
      "source": [
        "import gurobipy as gp\n",
        "from gurobipy import GRB\n",
        "from pyepo.model.grb import optGrbModel\n",
        "\n",
        "class myShortestPathModel2(optGrbModel):\n",
        "\n",
        "    def __init__(self, grid):\n",
        "        \"\"\"\n",
        "        Args:\n",
        "            grid (tuple of int): size of grid network\n",
        "        \"\"\"\n",
        "        self.grid = grid\n",
        "        self.arcs = self._getArcs()\n",
        "        super().__init__()\n",
        "\n",
        "    def _getModel(self):\n",
        "        \"\"\"\n",
        "        A method to build Gurobi model\n",
        "\n",
        "        Returns:\n",
        "            tuple: optimization model and variables\n",
        "        \"\"\"\n",
        "        # ceate a model\n",
        "        m = gp.Model(\"shortest path\")\n",
        "        # varibles\n",
        "        x = m.addVars(self.arcs, name=\"x\")\n",
        "        # sense\n",
        "        m.modelSense = GRB.MINIMIZE\n",
        "        # constraints\n",
        "        for i in range(self.grid[0]):\n",
        "            for j in range(self.grid[1]):\n",
        "                v = i * self.grid[1] + j\n",
        "                expr = 0\n",
        "                for e in self.arcs:\n",
        "                    # flow in\n",
        "                    if v == e[1]:\n",
        "                        expr += x[e]\n",
        "                    # flow out\n",
        "                    elif v == e[0]:\n",
        "                        expr -= x[e]\n",
        "                # source\n",
        "                if i == 0 and j == 0:\n",
        "                    m.addConstr(expr == -1)\n",
        "                # sink\n",
        "                elif i == self.grid[0] - 1 and j == self.grid[0] - 1:\n",
        "                    m.addConstr(expr == 1)\n",
        "                # transition\n",
        "                else:\n",
        "                    m.addConstr(expr == 0)\n",
        "        return m, x\n",
        "\n",
        "\n",
        "    def _getArcs(self):\n",
        "        \"\"\"\n",
        "        A helper method to get list of arcs for grid network\n",
        "\n",
        "        Returns:\n",
        "            list: arcs\n",
        "        \"\"\"\n",
        "        arcs = []\n",
        "        for i in range(self.grid[0]):\n",
        "            # edges on rows\n",
        "            for j in range(self.grid[1] - 1):\n",
        "                v = i * self.grid[1] + j\n",
        "                arcs.append((v, v + 1))\n",
        "            # edges in columns\n",
        "            if i == self.grid[0] - 1:\n",
        "                continue\n",
        "            for j in range(self.grid[1]):\n",
        "                v = i * self.grid[1] + j\n",
        "                arcs.append((v, v + self.grid[1]))\n",
        "        return arcs"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 7,
      "id": "a9e21c5c",
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "a9e21c5c",
        "outputId": "4c8c9fb5-7696-4674-936e-fb53d03ae330"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Restricted license - for non-production use only - expires 2026-11-23\n",
            "Obj: 2.2869939003139734\n",
            "(0, 1)\n",
            "(1, 2)\n",
            "(2, 3)\n",
            "(3, 8)\n",
            "(8, 9)\n",
            "(9, 14)\n",
            "(14, 19)\n",
            "(19, 24)\n"
          ]
        }
      ],
      "source": [
        "# solve model\n",
        "optmodel = myShortestPathModel2(grid=(5,5)) # init model\n",
        "optmodel.setObj(cost) # set objective function\n",
        "sol, obj = optmodel.solve() # solve\n",
        "# print res\n",
        "print('Obj: {}'.format(obj))\n",
        "for i, e in enumerate(optmodel.arcs):\n",
        "    if sol[i] > 1e-3:\n",
        "        print(e)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "3ef9c3a6",
      "metadata": {
        "id": "3ef9c3a6"
      },
      "source": [
        "### 2.3  Pre-defined Gurobi Model"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "9c406b8d",
      "metadata": {
        "id": "9c406b8d"
      },
      "source": [
        "PyEPO contains several pre-defined optimization models with GurobiPy and Pyomo."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 8,
      "id": "022aaa36",
      "metadata": {
        "id": "022aaa36"
      },
      "outputs": [],
      "source": [
        "# shortest path on the grid network\n",
        "from pyepo.model.grb import shortestPathModel"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 9,
      "id": "1bcc17e0",
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "1bcc17e0",
        "outputId": "2e988297-579b-4b51-8c66-a04918d6bc0f"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Obj: 2.2869939003139734\n",
            "(0, 1)\n",
            "(1, 2)\n",
            "(2, 3)\n",
            "(3, 8)\n",
            "(8, 9)\n",
            "(9, 14)\n",
            "(14, 19)\n",
            "(19, 24)\n"
          ]
        }
      ],
      "source": [
        "# solve model\n",
        "optmodel = shortestPathModel(grid=(5,5)) # init model\n",
        "optmodel.setObj(cost) # set objective function\n",
        "sol, obj = optmodel.solve() # solve\n",
        "# print res\n",
        "print('Obj: {}'.format(obj))\n",
        "for i, e in enumerate(optmodel.arcs):\n",
        "    if sol[i] > 1e-3:\n",
        "        print(e)"
      ]
    },
    {
      "cell_type": "markdown",
      "id": "6c31b375-d4f6-4bd1-a260-20089a45dd23",
      "metadata": {
        "id": "6c31b375-d4f6-4bd1-a260-20089a45dd23"
      },
      "source": [
        "### 2.4 Pre-defined MPAX Model\n",
        "PyEPO contains several pre-defined optimization models with MPAX."
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 10,
      "id": "de6feb58",
      "metadata": {
        "id": "de6feb58"
      },
      "outputs": [],
      "source": [
        "# shortest path on the grid network\n",
        "from pyepo.model.mpax import shortestPathModel"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": 13,
      "id": "22bc72c9-767b-4f73-838d-0173370ab0d3",
      "metadata": {
        "id": "22bc72c9-767b-4f73-838d-0173370ab0d3",
        "outputId": "dadb66b0-e389-410d-a563-243c8f6ce86b",
        "colab": {
          "base_uri": "https://localhost:8080/"
        }
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Obj: 2.286993980407715\n",
            "(0, 1)\n",
            "(1, 2)\n",
            "(2, 3)\n",
            "(3, 8)\n",
            "(8, 9)\n",
            "(9, 14)\n",
            "(14, 19)\n",
            "(19, 24)\n"
          ]
        }
      ],
      "source": [
        "# solve model\n",
        "optmodel = shortestPathModel(grid=(5,5)) # init model\n",
        "optmodel.setObj(cost) # set objective function\n",
        "sol, obj = optmodel.solve() # solve\n",
        "# print res\n",
        "print('Obj: {}'.format(obj))\n",
        "for i, e in enumerate(optmodel.arcs):\n",
        "    if sol[i] > 1e-3:\n",
        "        print(e)"
      ]
    },
    {
      "cell_type": "markdown",
      "source": [
        "## 3 Introducation to optMpaxModel"
      ],
      "metadata": {
        "id": "HItjiUQNSRU3"
      },
      "id": "HItjiUQNSRU3"
    },
    {
      "cell_type": "markdown",
      "source": [
        "In this section, we introduce how to model and solve optimization problems in **PyEPO** using [`MPAX`](https://github.com/MIT-Lu-Lab/MPAX), a PDHG (Primal-Dual Hybrid Gradient) optimization framework.  \n",
        "\n",
        "`optMpaxModel` is a PyEPO optimization model that leverages `MPAX `for solving **LP problems** (**not ILP**) using the PDHG algorithm. Unlike traditional solvers, MPAX is highly efficient on GPU, making it an excellent choice for large-scale decision-focused learning tasks.\n",
        "\n"
      ],
      "metadata": {
        "id": "XkCUNa_yTFfg"
      },
      "id": "XkCUNa_yTFfg"
    },
    {
      "cell_type": "markdown",
      "source": [
        "### 3.1 Mathematical Formulation\n",
        "\n",
        "The model solves a linear program (LP) of the form:\n",
        "\n",
        "$$\n",
        "\\min_{\\mathbf{x}} \\mathbf{c}^\\top \\mathbf{x}\n",
        "$$\n",
        "\n",
        "Subject to:  \n",
        "\n",
        "- Equality constraints: $\\mathbf{A} \\mathbf{x} = \\mathbf{b} $  \n",
        "- Inequality constraints: $ \\mathbf{G} \\mathbf{x} \\geq \\mathbf{h} $  \n",
        "- Variable bounds: $ \\mathbf{l} \\leq \\mathbf{x} \\leq \\mathbf{u} $  \n",
        "\n",
        "Where:  \n",
        "- $ c $ is the cost vector (objective function coefficients)  \n",
        "- $ A, b $ define the equality constraints\n",
        "- $ G, h $ define the inequality constraints\n",
        "- $ l, u $ define the variable bounds"
      ],
      "metadata": {
        "id": "rgB803EATr0G"
      },
      "id": "rgB803EATr0G"
    },
    {
      "cell_type": "markdown",
      "source": [
        "### 3.2 Model Parameters\n",
        "\n",
        "`optMpaxModel` only accepts model coefficients in matrix and vector form. This means:\n",
        "- Constraints must be explicitly provided as matrices and vectors (no symbolic constraints).\n",
        "- No direct support for constraints in Python functions (e.g., defining Ax = b in a callable function).\n",
        "- If `A` and `b` are not provided, it means **there are no equality constraints** in the problem.\n",
        "- If `G` and `h` are not provided, it means **there are no inequality constraints** in the problem.\n",
        "- The default value for `l` (lower bound) is `0`, meaning all variables are **non-negative** unless specified otherwise.\n",
        "- The default value for `u` (upper bound) is infinity, meaning variables are unbounded unless explicitly set."
      ],
      "metadata": {
        "id": "R9ZWpr8aVBer"
      },
      "id": "R9ZWpr8aVBer"
    },
    {
      "cell_type": "code",
      "source": [
        "import numpy as np\n",
        "import torch\n",
        "# random seed for reproducibility\n",
        "np.random.seed(42)\n",
        "# number of variables\n",
        "num_vars = 10\n",
        "# random equality and inequality constraints\n",
        "A = np.random.rand(3, num_vars)  # 3 equality constraints\n",
        "b = np.random.rand(3)\n",
        "G = np.random.rand(5, num_vars)  # 5 inequality constraints\n",
        "h = np.random.rand(5)"
      ],
      "metadata": {
        "id": "IE8ZLbRVWxRr"
      },
      "id": "IE8ZLbRVWxRr",
      "execution_count": 14,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "source": [
        "### 3.3 Create Model and Solve\n",
        "\n",
        "Now, we create an optMpaxModel instance with the defined constraints.\n",
        "\n",
        "In addition to model parameters, we also have two additional settings:  \n",
        "- **`use_sparse_matrix`** (`bool`, default: `True`) – Stores constraint matrices in a sparse format or not. Choosing the right matrix type is crucial for optimization efficiency.  \n",
        "- **`minimize`** (`bool`, default: `True`) – Defines whether to minimize the objective function.  "
      ],
      "metadata": {
        "id": "zv2HTnkHXB7K"
      },
      "id": "zv2HTnkHXB7K"
    },
    {
      "cell_type": "code",
      "source": [
        "from pyepo.model.mpax import optMpaxModel\n",
        "optmodel = optMpaxModel(A=A, b=b, G=G, h=h, use_sparse_matrix=False, minimize=True)"
      ],
      "metadata": {
        "id": "9jnRVfn5XBbm"
      },
      "id": "9jnRVfn5XBbm",
      "execution_count": 15,
      "outputs": []
    },
    {
      "cell_type": "code",
      "execution_count": 16,
      "id": "c664269d-6702-47f7-8794-9b57f293980f",
      "metadata": {
        "id": "c664269d-6702-47f7-8794-9b57f293980f",
        "outputId": "c7e034fd-53b8-442f-e0fc-1bad34ebeda0",
        "colab": {
          "base_uri": "https://localhost:8080/"
        }
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Objective Value: 0.8743340969085693\n",
            "Solution: tensor([0.0262, 0.0000, 0.0000, 0.3298, 0.0000, 0.0000, 0.5673, 0.0029, 0.0000,\n",
            "        0.4142])\n"
          ]
        }
      ],
      "source": [
        "# generate a random cost vector\n",
        "cost = np.random.rand(num_vars)\n",
        "# solve the model\n",
        "optmodel.setObj(cost)\n",
        "sol, obj = optmodel.solve()\n",
        "# print results\n",
        "print(f\"Objective Value: {obj}\")\n",
        "print(f\"Solution: {sol}\")"
      ]
    },
    {
      "cell_type": "code",
      "source": [],
      "metadata": {
        "id": "qO-myaX8YiEN"
      },
      "id": "qO-myaX8YiEN",
      "execution_count": null,
      "outputs": []
    }
  ],
  "metadata": {
    "colab": {
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python [conda env:.conda-mpax] *",
      "language": "python",
      "name": "conda-env-.conda-mpax-py"
    },
    "language_info": {
      "codemirror_mode": {
        "name": "ipython",
        "version": 3
      },
      "file_extension": ".py",
      "mimetype": "text/x-python",
      "name": "python",
      "nbconvert_exporter": "python",
      "pygments_lexer": "ipython3",
      "version": "3.12.3"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}